Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Project structure

Keep this in sync with the actual tree (the docs-in-sync rule).

All modules present: main, app, askpass, config, doctor, export, forwards, import, model, paths, search, secrets, ssh, state, store, tailscale, transfer/{mod,worker,pane,screen,e2e}, testsupport (test-only), vault, ui/{mod,list,help,widgets,wizard,browse,settings,sites,transfer,forward_popup,forwards,two_factor}. (error.rs was removed; the codebase uses anyhow throughout.)

Repository

ssh-tui/                 (crate/binary name: `sshelf`)
├── Cargo.toml
├── CONTRIBUTING.md      contributor guide + docs-in-sync rule
├── README.md           (M8) user-facing intro + positioning
├── SECURITY.md         (M8) threat model for OSS users (mirrors docs/security.md)
├── LICENSE-MIT         (M1)
├── LICENSE-APACHE      (M1)
├── docs/               living documentation (this directory)
└── src/                see below

src/ modules

FileResponsibility
main.rsEntry/dispatch. If SSHELF_ASKPASS is set → askpass mode (read argv[1]). Else clap parses: default TUI, or subcommands (import, list, add, add --from-ssh). Also the connect tail both exec paths share: the first-connect prompt and store, the 2FA prompt, the echo-off terminal reader, and the handoff.
app.rsApp state + synchronous event loop + screen routing (component orchestration).
model.rsHost + Site structs (+ AuthMethod); Host::with_site_defaults/find_site (site inheritance); serde derives.
store.rsLoad/save hosts.toml with atomic write (exclusive temp + rename); create_exclusive, the shared “create this file and fail if the name is taken” helper the forward logs use too.
state.rsFrecency state (use_count, last_used) load/save (state.json); score computation.
forwards.rsBackground port-forwards: the ForwardSpec/ForwardEntry model, the -L/-R/-D argv builder, spawn (detached ssh -N + readiness/error mapping), PID liveness/kill via ps/kill, reconcile, and forwards.json load/save.
secrets.rsSecretStore trait → keyring backend + age-vault fallback; zeroize on secrets. Also the read-only introspection doctor needs: which backend is active, a write-read-delete probe, and (vault only) the stored ids.
ssh.rsBuild ssh argv from a Host; the JumpPlan that decides whether a jump chain is -J, an explicit ProxyCommand, or a terminal prompt; terminal teardown + exec() handoff; askpass env wiring (including the per-command SSHELF_CONNECT_ID); the first-connect probe and verify commands; the tmux new-window -d/split-window -d spawn and the rules for what may cross that boundary.
askpass.rsHeadless askpass entry: inspect argv[1] and answer only the prompt shape matching SSHELF_SECRET_KIND (a login password, or this host’s own key passphrase per SSHELF_IDENTITY_FILES), or a queued 2FA code (SSHELF_2FA_CODE) for anything that is not secret-shaped; else decline. Also the askpass-<connect id> marker that turns a repeated secret prompt into one “was refused” line and a decline, and the sweep of stale markers.
sshcmd.rssshelf add --from-ssh: read one ssh command line with OpenSSH’s option grammar and map it onto a Host (the fields it models, the rest as extra_args, and a note for each option it drops). Pure; the working directory a relative -i resolves against is its only outside input.
first_connect.rsSaving the secret on first connect: the trigger as a pure function over auth, stored secret, encrypted key, jump plan and tmux; ssh-keygen -y for encrypted keys; the bounded probe and verify runs and their exit-status rules. The prompt and the exec stay in main.rs.
search.rsFuzzy filter (nucleo-matcher) + frecency ranking + per-row match indices for highlight.
import.rsssh2-config parse of ~/.ssh/config → Host mapping; warn on unsupported Match/Include. Also the shape (ImportResult) and add-only plumbing (new_hosts, missing_sites) every importer shares.
tailscale.rssshelf import --tailscale: locate the user’s tailscale binary, run status --json, map eligible peers → hosts (MagicDNS name/FQDN, tailnet → site, ACL tags). Pure parser over &str; the only process spawn is isolated in one function.
doctor.rssshelf doctor: one pure function per check (OpenSSH version, hosts-file parse + duplicates, secret backend, dangling sites, orphaned secrets, ssh-agent, export freshness, config-directory permissions) over inputs the caller gathers, plus the report’s rendering and exit-code rule.
export.rsRender the database as an ssh_config Include fragment (site defaults resolved, -o extras translated); write to ssh_config in the config dir; auto-refresh on hosts saves once the file exists.
paths.rsetcetera path resolution (config/data dirs); file paths; ensure_private_dir, which creates a directory 0700 and re-applies that mode only to one sshelf owns; runtime_dir, the private directory the transfer screen’s mux sessions and the askpass markers share.
display.rsOne sanitizer for plain CLI output: control characters, C1 codes and bidirectional overrides become U+FFFD, and the same set is what the add form and the importers refuse.
config.rsPreferences: decay_rate, default_sort, accent color, tmux mode; writes a commented default on first run.
transfer/mod.rsFile-transfer core: ssh-ControlMaster + sftp argv builders, the worker↔UI message protocol (WorkerCmd/WorkerEvent), and progress math.
transfer/worker.rsBackground worker thread: owns the ControlMaster and the 0700 session directory its socket lives in (open/readiness/teardown), lists remote dirs (sftp ls -lan) and creates them (sftp mkdir) under a deadline and an output cap, runs sftp get/put transfers with progress + cancel, and installs a single file sent either way with a no-replace link (link() locally, sftp ln on the server).
transfer/pane.rsOne side’s browsing state (fuzzy filter + selection + nav + positional marks, reusing search); read_local_dir for the local side; RemoteEntry→PaneEntry.
transfer/screen.rsThe dual-pane TransferScreen: two panes over one session, key handling, the send queue (marks → one transfer at a time, skips vs failures), the new-directory input, draining worker events.
ui/list.rsHost list rendering + match highlighting + selection.
ui/transfer.rsRenders the transfer screen (two panes + mark glyphs + the new-directory input + progress/status + hint bar) from a borrowed view.
ui/wizard.rsAuth-aware add/edit form: fields, validation, key picker, opens the file browser.
ui/browse.rsFile-browser modal (fuzzy-filtered) for picking a key file anywhere on disk.
ui/settings.rsSettings screen (F2): config-file display, editable hosts-file location, tmux-mode toggle.
ui/sites.rsSites manager (F3): list + add/edit/delete sites and their optional defaults; emits renames for the app to cascade.
ui/forward_popup.rsNew-port-forward popup (Ctrl-f): kind chooser (Local/Remote/Dynamic) + ports/host fields + validation; emits a ForwardSpec for the app to spawn.
ui/forwards.rsPort-forwards manager (F4): lists all active forwards from a live snapshot; emits a kill request for the app to act on.
ui/two_factor.rs2FA code popup shown before connecting to a requires_2fa host; emits the entered code for the app to queue + supply via askpass.
ui/help.rsHelp overlay.
ui/widgets.rsShared widgets: single-line text input (hand-rolled), keybind hint bar, confirm modal.

Data flow

paths ──▶ store ──▶ model(Host[])  ┐
paths ──▶ state ──▶ frecency       ├─▶ search ──▶ ui ──▶ (Enter) ──▶ ssh ──▶ exec
                                   ┘                                  │
secrets ◀── ui (store on add/edit)                     askpass ◀── ssh (via SSH_ASKPASS)
   └────────────────────────────────────────────────────▶ secrets (retrieve)

Conventions

  • One responsibility per module; ssh.rs is the only place that calls exec(); secrets.rs is the only place that touches the keyring/vault.
  • No unwrap()/expect() on fallible I/O in non-test code; return errors and surface them in the UI.
  • Every new/moved module updates this file.